@@ -0,0 +1,453 @@ |
||
1 |
+require 'delegate' |
|
2 |
+require 'net/imap' |
|
3 |
+require 'mail' |
|
4 |
+ |
|
5 |
+module Agents |
|
6 |
+ class ImapFolderAgent < Agent |
|
7 |
+ cannot_receive_events! |
|
8 |
+ |
|
9 |
+ default_schedule "every_30m" |
|
10 |
+ |
|
11 |
+ description <<-MD |
|
12 |
+ |
|
13 |
+ The ImapFolderAgent checks an IMAP server in specified folders |
|
14 |
+ and creates Events based on new unread mails. |
|
15 |
+ |
|
16 |
+ Specify an IMAP server to connect with `host`, and set `ssl` to |
|
17 |
+ true if the server supports IMAP over SSL. Specify `port` if |
|
18 |
+ you need to connect to a port other than standard (143 or 993 |
|
19 |
+ depending on the `ssl` value). |
|
20 |
+ |
|
21 |
+ Specify login credentials in `username` and `password`. |
|
22 |
+ |
|
23 |
+ List the names of folders to check in `folders`. |
|
24 |
+ |
|
25 |
+ To narrow mails by conditions, build a `conditions` hash with |
|
26 |
+ the following keys: |
|
27 |
+ |
|
28 |
+ - "subject" |
|
29 |
+ - "body" |
|
30 |
+ |
|
31 |
+ Specify a regular expression to match against the decoded |
|
32 |
+ subject/body of each mail. |
|
33 |
+ |
|
34 |
+ Use the `(?i)` directive for case-insensitive search. For |
|
35 |
+ example, a pattern `(?i)alert` will match "alert", "Alert" |
|
36 |
+ or "ALERT". You can also make only a part of a pattern to |
|
37 |
+ work case-insensitively: `Re: (?i:alert)` will match either |
|
38 |
+ "Re: Alert" or "Re: alert", but not "RE: alert". |
|
39 |
+ |
|
40 |
+ When a mail has multiple non-attachment text parts, they are |
|
41 |
+ prioritized according to the `mime_types` option (which see |
|
42 |
+ below) and the first part that matches a "body" pattern, if |
|
43 |
+ specified, will be chosen as the "body" value in a created |
|
44 |
+ event. |
|
45 |
+ |
|
46 |
+ Named captues will appear in the "matches" hash in a created |
|
47 |
+ event. |
|
48 |
+ |
|
49 |
+ - "from", "to", "cc" |
|
50 |
+ |
|
51 |
+ Specify a shell glob pattern string that is matched against |
|
52 |
+ mail addresses extracted from the corresponding header |
|
53 |
+ values of each mail. |
|
54 |
+ |
|
55 |
+ Patterns match addresses in case insensitive manner. |
|
56 |
+ |
|
57 |
+ Multiple pattern strings can be specified in an array, in |
|
58 |
+ which case a mail is selected if any of the patterns |
|
59 |
+ matches. (i.e. patterns are OR'd) |
|
60 |
+ |
|
61 |
+ - "mime_types" |
|
62 |
+ |
|
63 |
+ Specify an array of MIME types to tell which non-attachment |
|
64 |
+ part of a mail among its text/* parts should be used as mail |
|
65 |
+ body. The default value is `['text/plain', 'text/enriched', |
|
66 |
+ 'text/html']`. |
|
67 |
+ |
|
68 |
+ - "has_attachment" |
|
69 |
+ |
|
70 |
+ Setting this to true or false means only mails that does or does |
|
71 |
+ not have an attachment are selected. |
|
72 |
+ |
|
73 |
+ If this key is unspecified or set to null, it is ignored. |
|
74 |
+ |
|
75 |
+ Set `mark_as_read` to true to mark found mails as read. |
|
76 |
+ |
|
77 |
+ Each agent instance memorizes a list of unread mails that are |
|
78 |
+ found in the last run, so even if you change a set of conditions |
|
79 |
+ so that it matches mails that are missed previously, they will |
|
80 |
+ not show up as new events. Also, in order to avoid duplicated |
|
81 |
+ notification it keeps a list of Message-Id's of 100 most recent |
|
82 |
+ mails, so if multiple mails of the same Message-Id are found, |
|
83 |
+ you will only see one event out of them. |
|
84 |
+ MD |
|
85 |
+ |
|
86 |
+ event_description <<-MD |
|
87 |
+ Events look like this: |
|
88 |
+ |
|
89 |
+ { |
|
90 |
+ "folder": "INBOX", |
|
91 |
+ "subject": "...", |
|
92 |
+ "from": "Nanashi <nanashi.gombeh@example.jp>", |
|
93 |
+ "to": ["Jane <jane.doe@example.com>"], |
|
94 |
+ "cc": [], |
|
95 |
+ "date": "2014-05-10T03:47:20+0900", |
|
96 |
+ "mime_type": "text/plain", |
|
97 |
+ "body": "Hello,\n\n...", |
|
98 |
+ "matches": { |
|
99 |
+ } |
|
100 |
+ } |
|
101 |
+ MD |
|
102 |
+ |
|
103 |
+ IDCACHE_SIZE = 100 |
|
104 |
+ |
|
105 |
+ FNM_FLAGS = [:FNM_CASEFOLD, :FNM_EXTGLOB].inject(0) { |flags, sym| |
|
106 |
+ if File.const_defined?(sym) |
|
107 |
+ flags | File.const_get(sym) |
|
108 |
+ else |
|
109 |
+ flags |
|
110 |
+ end |
|
111 |
+ } |
|
112 |
+ |
|
113 |
+ def working? |
|
114 |
+ event_created_within?(options['expected_update_period_in_days']) && !recent_error_logs? |
|
115 |
+ end |
|
116 |
+ |
|
117 |
+ def default_options |
|
118 |
+ { |
|
119 |
+ 'expected_update_period_in_days' => "1", |
|
120 |
+ 'host' => 'imap.gmail.com', |
|
121 |
+ 'ssl' => true, |
|
122 |
+ 'username' => 'your.account', |
|
123 |
+ 'password' => 'your.password', |
|
124 |
+ 'folders' => %w[INBOX], |
|
125 |
+ 'conditions' => {} |
|
126 |
+ } |
|
127 |
+ end |
|
128 |
+ |
|
129 |
+ def validate_options |
|
130 |
+ %w[host username password].each { |key| |
|
131 |
+ String === options[key] or |
|
132 |
+ errors.add(:base, '%s is required and must be a string' % key) |
|
133 |
+ } |
|
134 |
+ |
|
135 |
+ if options['port'].present? |
|
136 |
+ errors.add(:base, "port must be a positive integer") unless is_positive_integer?(options['port']) |
|
137 |
+ end |
|
138 |
+ |
|
139 |
+ %w[ssl mark_as_read].each { |key| |
|
140 |
+ if options[key].present? |
|
141 |
+ case options[key] |
|
142 |
+ when true, false |
|
143 |
+ else |
|
144 |
+ errors.add(:base, '%s must be a boolean value' % key) |
|
145 |
+ end |
|
146 |
+ end |
|
147 |
+ } |
|
148 |
+ |
|
149 |
+ case mime_types = options['mime_types'] |
|
150 |
+ when nil |
|
151 |
+ when Array |
|
152 |
+ mime_types.all? { |mime_type| |
|
153 |
+ String === mime_type && mime_type.start_with?('text/') |
|
154 |
+ } or errors.add(:base, 'mime_types may only contain strings that match "text/*".') |
|
155 |
+ if mime_types.empty? |
|
156 |
+ errors.add(:base, 'mime_types should not be empty') |
|
157 |
+ end |
|
158 |
+ else |
|
159 |
+ errors.add(:base, 'mime_types must be an array') |
|
160 |
+ end |
|
161 |
+ |
|
162 |
+ case folders = options['folders'] |
|
163 |
+ when nil |
|
164 |
+ when Array |
|
165 |
+ folders.all? { |folder| |
|
166 |
+ String === folder |
|
167 |
+ } or errors.add(:base, 'folders may only contain strings') |
|
168 |
+ if folders.empty? |
|
169 |
+ errors.add(:base, 'folders should not be empty') |
|
170 |
+ end |
|
171 |
+ else |
|
172 |
+ errors.add(:base, 'folders must be an array') |
|
173 |
+ end |
|
174 |
+ |
|
175 |
+ case conditions = options['conditions'] |
|
176 |
+ when nil |
|
177 |
+ when Hash |
|
178 |
+ conditions.each { |key, value| |
|
179 |
+ value.present? or next |
|
180 |
+ case key |
|
181 |
+ when 'subject', 'body' |
|
182 |
+ case value |
|
183 |
+ when String |
|
184 |
+ begin |
|
185 |
+ Regexp.new(value) |
|
186 |
+ rescue |
|
187 |
+ errors.add(:base, 'conditions.%s contains an invalid regexp' % key) |
|
188 |
+ end |
|
189 |
+ else |
|
190 |
+ errors.add(:base, 'conditions.%s contains a non-string object' % key) |
|
191 |
+ end |
|
192 |
+ when 'from', 'to', 'cc' |
|
193 |
+ Array(value).each { |pattern| |
|
194 |
+ case pattern |
|
195 |
+ when String |
|
196 |
+ begin |
|
197 |
+ glob_match?(pattern, '') |
|
198 |
+ rescue |
|
199 |
+ errors.add(:base, 'conditions.%s contains an invalid glob pattern' % key) |
|
200 |
+ end |
|
201 |
+ else |
|
202 |
+ errors.add(:base, 'conditions.%s contains a non-string object' % key) |
|
203 |
+ end |
|
204 |
+ } |
|
205 |
+ when 'has_attachment' |
|
206 |
+ case value |
|
207 |
+ when true, false |
|
208 |
+ else |
|
209 |
+ errors.add(:base, 'conditions.%s must be a boolean value or null' % key) |
|
210 |
+ end |
|
211 |
+ end |
|
212 |
+ } |
|
213 |
+ else |
|
214 |
+ errors.add(:base, 'conditions must be a hash') |
|
215 |
+ end |
|
216 |
+ |
|
217 |
+ if options['expected_update_period_in_days'].present? |
|
218 |
+ errors.add(:base, "Invalid expected_update_period_in_days format") unless is_positive_integer?(options['expected_update_period_in_days']) |
|
219 |
+ end |
|
220 |
+ end |
|
221 |
+ |
|
222 |
+ def check |
|
223 |
+ # 'seen' keeps a hash of { uidvalidity => uids, ... } which |
|
224 |
+ # lists unread mails in watched folders. |
|
225 |
+ seen = memory['seen'] || {} |
|
226 |
+ new_seen = Hash.new { |hash, key| |
|
227 |
+ hash[key] = [] |
|
228 |
+ } |
|
229 |
+ |
|
230 |
+ # 'notified' keeps an array of message-ids of {IDCACHE_SIZE} |
|
231 |
+ # most recent notified mails. |
|
232 |
+ notified = memory['notified'] || [] |
|
233 |
+ |
|
234 |
+ each_unread_mail { |mail| |
|
235 |
+ new_seen[mail.uidvalidity] << mail.uid |
|
236 |
+ |
|
237 |
+ next if (uids = seen[mail.uidvalidity]) && uids.include?(mail.uid) |
|
238 |
+ |
|
239 |
+ body_parts = mail.body_parts(mime_types) |
|
240 |
+ matched_part = nil |
|
241 |
+ matches = {} |
|
242 |
+ |
|
243 |
+ options['conditions'].all? { |key, value| |
|
244 |
+ case key |
|
245 |
+ when 'subject' |
|
246 |
+ value.present? or next true |
|
247 |
+ re = Regexp.new(value) |
|
248 |
+ if m = re.match(mail.subject) |
|
249 |
+ m.names.each { |name| |
|
250 |
+ matches[name] = m[name] |
|
251 |
+ } |
|
252 |
+ true |
|
253 |
+ else |
|
254 |
+ false |
|
255 |
+ end |
|
256 |
+ when 'body' |
|
257 |
+ value.present? or next true |
|
258 |
+ re = Regexp.new(value) |
|
259 |
+ matched_part = body_parts.find { |part| |
|
260 |
+ if m = re.match(part.decoded) |
|
261 |
+ m.names.each { |name| |
|
262 |
+ matches[name] = m[name] |
|
263 |
+ } |
|
264 |
+ true |
|
265 |
+ else |
|
266 |
+ false |
|
267 |
+ end |
|
268 |
+ } |
|
269 |
+ when 'from', 'to', 'cc' |
|
270 |
+ value.present? or next true |
|
271 |
+ mail.header[key].addresses.any? { |address| |
|
272 |
+ Array(value).any? { |pattern| |
|
273 |
+ glob_match?(pattern, address) |
|
274 |
+ } |
|
275 |
+ } |
|
276 |
+ when 'has_attachment' |
|
277 |
+ value == mail.has_attachment? |
|
278 |
+ else |
|
279 |
+ log 'Unknown condition key ignored: %s' % key |
|
280 |
+ true |
|
281 |
+ end |
|
282 |
+ } or next |
|
283 |
+ |
|
284 |
+ unless notified.include?(mail.message_id) |
|
285 |
+ matched_part ||= body_parts.first |
|
286 |
+ |
|
287 |
+ if matched_part |
|
288 |
+ mime_type = matched_part.mime_type |
|
289 |
+ body = matched_part.decoded |
|
290 |
+ else |
|
291 |
+ mime_type = 'text/plain' |
|
292 |
+ body = '' |
|
293 |
+ end |
|
294 |
+ |
|
295 |
+ create_event :payload => { |
|
296 |
+ 'folder' => mail.folder, |
|
297 |
+ 'subject' => mail.subject, |
|
298 |
+ 'from' => mail.from_addrs.first, |
|
299 |
+ 'to' => mail.to_addrs, |
|
300 |
+ 'cc' => mail.cc_addrs, |
|
301 |
+ 'date' => (mail.date.iso8601 rescue nil), |
|
302 |
+ 'mime_type' => mime_type, |
|
303 |
+ 'body' => body, |
|
304 |
+ 'matches' => matches, |
|
305 |
+ 'has_attachment' => mail.has_attachment?, |
|
306 |
+ } |
|
307 |
+ |
|
308 |
+ notified << mail.message_id if mail.message_id |
|
309 |
+ end |
|
310 |
+ |
|
311 |
+ if options['mark_as_read'] |
|
312 |
+ log 'Marking as read' |
|
313 |
+ mail.mark_as_read |
|
314 |
+ end |
|
315 |
+ } |
|
316 |
+ |
|
317 |
+ notified.slice!(0...-IDCACHE_SIZE) if notified.size > IDCACHE_SIZE |
|
318 |
+ |
|
319 |
+ memory['seen'] = new_seen |
|
320 |
+ memory['notified'] = notified |
|
321 |
+ save! |
|
322 |
+ end |
|
323 |
+ |
|
324 |
+ def each_unread_mail |
|
325 |
+ host, port, ssl, username = options.values_at(:host, :port, :ssl, :username) |
|
326 |
+ |
|
327 |
+ log "Connecting to #{host}#{':%d' % port if port}#{' via SSL' if ssl}" |
|
328 |
+ Client.open(host, Integer(port), ssl) { |imap| |
|
329 |
+ log "Logging in as #{username}" |
|
330 |
+ imap.login(username, options[:password]) |
|
331 |
+ |
|
332 |
+ options['folders'].each { |folder| |
|
333 |
+ log "Selecting the folder: %s" % folder |
|
334 |
+ |
|
335 |
+ imap.select(folder) |
|
336 |
+ |
|
337 |
+ unseen = imap.search('UNSEEN') |
|
338 |
+ |
|
339 |
+ if unseen.empty? |
|
340 |
+ log "No unread mails" |
|
341 |
+ next |
|
342 |
+ end |
|
343 |
+ |
|
344 |
+ imap.fetch_mails(unseen).each { |mail| |
|
345 |
+ yield mail |
|
346 |
+ } |
|
347 |
+ } |
|
348 |
+ } |
|
349 |
+ ensure |
|
350 |
+ log 'Connection closed' |
|
351 |
+ end |
|
352 |
+ |
|
353 |
+ def mime_types |
|
354 |
+ options['mime_types'] || %w[text/plain text/enriched text/html] |
|
355 |
+ end |
|
356 |
+ |
|
357 |
+ private |
|
358 |
+ |
|
359 |
+ def is_positive_integer?(value) |
|
360 |
+ Integer(value) >= 0 |
|
361 |
+ rescue |
|
362 |
+ false |
|
363 |
+ end |
|
364 |
+ |
|
365 |
+ def glob_match?(pattern, value) |
|
366 |
+ File.fnmatch?(pattern, value, FNM_FLAGS) |
|
367 |
+ end |
|
368 |
+ |
|
369 |
+ class Client < ::Net::IMAP |
|
370 |
+ class << self |
|
371 |
+ def open(host, port, ssl) |
|
372 |
+ imap = new(host, port, ssl) |
|
373 |
+ yield imap |
|
374 |
+ ensure |
|
375 |
+ imap.disconnect |
|
376 |
+ end |
|
377 |
+ end |
|
378 |
+ |
|
379 |
+ def select(folder) |
|
380 |
+ ret = super(@folder = folder) |
|
381 |
+ @uidvalidity = responses['UIDVALIDITY'].last |
|
382 |
+ ret |
|
383 |
+ end |
|
384 |
+ |
|
385 |
+ def fetch_mails(set) |
|
386 |
+ fetch(set, %w[UID RFC822.HEADER]).map { |data| |
|
387 |
+ Message.new(self, data, folder: @folder, uidvalidity: @uidvalidity) |
|
388 |
+ } |
|
389 |
+ end |
|
390 |
+ end |
|
391 |
+ |
|
392 |
+ class Message < SimpleDelegator |
|
393 |
+ DEFAULT_BODY_MIME_TYPES = %w[text/plain text/enriched text/html] |
|
394 |
+ |
|
395 |
+ attr_reader :uid, :folder, :uidvalidity |
|
396 |
+ |
|
397 |
+ def initialize(client, fetch_data, props = {}) |
|
398 |
+ @client = client |
|
399 |
+ props.each { |key, value| |
|
400 |
+ instance_variable_set(:"@#{key}", value) |
|
401 |
+ } |
|
402 |
+ attr = fetch_data.attr |
|
403 |
+ @uid = attr['UID'] |
|
404 |
+ super(Mail.read_from_string(attr['RFC822.HEADER'])) |
|
405 |
+ end |
|
406 |
+ |
|
407 |
+ def has_attachment? |
|
408 |
+ @has_attachment ||= |
|
409 |
+ begin |
|
410 |
+ data = @client.uid_fetch(@uid, 'BODYSTRUCTURE').first |
|
411 |
+ struct_has_attachment?(data.attr['BODYSTRUCTURE']) |
|
412 |
+ end |
|
413 |
+ end |
|
414 |
+ |
|
415 |
+ def fetch |
|
416 |
+ @parsed ||= |
|
417 |
+ begin |
|
418 |
+ data = @client.uid_fetch(@uid, 'BODY.PEEK[]').first |
|
419 |
+ Mail.read_from_string(data.attr['BODY[]']) |
|
420 |
+ end |
|
421 |
+ end |
|
422 |
+ |
|
423 |
+ def body_parts(mime_types = DEFAULT_BODY_MIME_TYPES) |
|
424 |
+ mail = fetch |
|
425 |
+ if mail.multipart? |
|
426 |
+ mail.body.set_sort_order(mime_types) |
|
427 |
+ mail.body.sort_parts! |
|
428 |
+ mail.all_parts |
|
429 |
+ else |
|
430 |
+ [mail] |
|
431 |
+ end.reject { |part| |
|
432 |
+ part.multipart? || part.attachment? || !part.text? || |
|
433 |
+ !mime_types.include?(part.mime_type) |
|
434 |
+ } |
|
435 |
+ end |
|
436 |
+ |
|
437 |
+ def mark_as_read |
|
438 |
+ @client.uid_store(@uid, '+FLAGS', [:Seen]) |
|
439 |
+ end |
|
440 |
+ |
|
441 |
+ private |
|
442 |
+ |
|
443 |
+ def struct_has_attachment?(struct) |
|
444 |
+ struct.multipart? && ( |
|
445 |
+ struct.subtype == 'MIXED' || |
|
446 |
+ struct.parts.any? { |part| |
|
447 |
+ struct_has_attachment?(part) |
|
448 |
+ } |
|
449 |
+ ) |
|
450 |
+ end |
|
451 |
+ end |
|
452 |
+ end |
|
453 |
+end |
@@ -0,0 +1,22 @@ |
||
1 |
+From: Nanashi <nanashi.gombeh@example.jp> |
|
2 |
+Date: Fri, 9 May 2014 16:00:00 +0900 |
|
3 |
+Message-ID: <foo.123@mail.example.jp> |
|
4 |
+Subject: some subject |
|
5 |
+To: Jane <jane.doe@example.com>, John <john.doe@example.com> |
|
6 |
+MIME-Version: 1.0 |
|
7 |
+Content-Type: multipart/alternative; boundary=d8c92622e09101e4bc833685557b |
|
8 |
+ |
|
9 |
+--d8c92622e09101e4bc833685557b |
|
10 |
+Content-Type: text/plain; charset=UTF-8 |
|
11 |
+ |
|
12 |
+Some plain text |
|
13 |
+Some second line |
|
14 |
+ |
|
15 |
+--d8c92622e09101e4bc833685557b |
|
16 |
+Content-Type: text/html; charset=UTF-8 |
|
17 |
+Content-Transfer-Encoding: quoted-printable |
|
18 |
+ |
|
19 |
+<div dir=3D"ltr">Some HTML document<br> |
|
20 |
+Some second line of HTML<br></div> |
|
21 |
+ |
|
22 |
+--d8c92622e09101e4bc833685557b-- |
@@ -0,0 +1,20 @@ |
||
1 |
+From: John <john.doe@example.com> |
|
2 |
+Date: Fri, 9 May 2014 17:00:00 +0900 |
|
3 |
+Message-ID: <bar.456@mail.example.com> |
|
4 |
+Subject: Re: some subject |
|
5 |
+To: Jane <jane.doe@example.com>, Nanashi <nanashi.gombeh@example.jp> |
|
6 |
+MIME-Version: 1.0 |
|
7 |
+Content-Type: multipart/alternative; boundary=d8c92622e09101e4bc833685557b |
|
8 |
+ |
|
9 |
+--d8c92622e09101e4bc833685557b |
|
10 |
+Content-Type: text/plain; charset=UTF-8 |
|
11 |
+ |
|
12 |
+Some reply |
|
13 |
+ |
|
14 |
+--d8c92622e09101e4bc833685557b |
|
15 |
+Content-Type: text/html; charset=UTF-8 |
|
16 |
+Content-Transfer-Encoding: quoted-printable |
|
17 |
+ |
|
18 |
+<div dir=3D"ltr">Some HTML reply<br></div> |
|
19 |
+ |
|
20 |
+--d8c92622e09101e4bc833685557b-- |
@@ -0,0 +1,242 @@ |
||
1 |
+require 'spec_helper' |
|
2 |
+require 'time' |
|
3 |
+ |
|
4 |
+describe Agents::ImapFolderAgent do |
|
5 |
+ describe 'checking IMAP' do |
|
6 |
+ before do |
|
7 |
+ @site = { |
|
8 |
+ 'expected_update_period_in_days' => 1, |
|
9 |
+ 'host' => 'mail.example.net', |
|
10 |
+ 'ssl' => true, |
|
11 |
+ 'username' => 'foo', |
|
12 |
+ 'password' => 'bar', |
|
13 |
+ 'folders' => ['INBOX'], |
|
14 |
+ 'conditions' => { |
|
15 |
+ } |
|
16 |
+ } |
|
17 |
+ @checker = Agents::ImapFolderAgent.new(:name => 'Example', :options => @site, :keep_events_for => 2) |
|
18 |
+ @checker.user = users(:bob) |
|
19 |
+ @checker.save! |
|
20 |
+ |
|
21 |
+ message_mixin = Module.new { |
|
22 |
+ def folder |
|
23 |
+ 'INBOX' |
|
24 |
+ end |
|
25 |
+ |
|
26 |
+ def uidvalidity |
|
27 |
+ '100' |
|
28 |
+ end |
|
29 |
+ |
|
30 |
+ def has_attachment? |
|
31 |
+ false |
|
32 |
+ end |
|
33 |
+ |
|
34 |
+ def body_parts(mime_types = %[text/plain text/enriched text/html]) |
|
35 |
+ mime_types.map { |type| |
|
36 |
+ all_parts.find { |part| |
|
37 |
+ part.mime_type == type |
|
38 |
+ } |
|
39 |
+ }.compact |
|
40 |
+ end |
|
41 |
+ } |
|
42 |
+ |
|
43 |
+ @mails = [ |
|
44 |
+ Mail.read(Rails.root.join('spec/data_fixtures/imap1.eml')).tap { |mail| |
|
45 |
+ mail.extend(message_mixin) |
|
46 |
+ stub(mail).uid.returns(1) |
|
47 |
+ }, |
|
48 |
+ Mail.read(Rails.root.join('spec/data_fixtures/imap2.eml')).tap { |mail| |
|
49 |
+ mail.extend(message_mixin) |
|
50 |
+ stub(mail).uid.returns(2) |
|
51 |
+ stub(mail).has_attachment?.returns(true) |
|
52 |
+ }, |
|
53 |
+ ] |
|
54 |
+ |
|
55 |
+ stub(@checker).each_unread_mail.returns { |yielder| |
|
56 |
+ @mails.each(&yielder) |
|
57 |
+ } |
|
58 |
+ |
|
59 |
+ @payloads = [ |
|
60 |
+ { |
|
61 |
+ 'folder' => 'INBOX', |
|
62 |
+ 'from' => 'nanashi.gombeh@example.jp', |
|
63 |
+ 'to' => ['jane.doe@example.com', 'john.doe@example.com'], |
|
64 |
+ 'cc' => [], |
|
65 |
+ 'date' => '2014-05-09T16:00:00+09:00', |
|
66 |
+ 'subject' => 'some subject', |
|
67 |
+ 'body' => "Some plain text\nSome second line\n", |
|
68 |
+ 'has_attachment' => false, |
|
69 |
+ 'matches' => {}, |
|
70 |
+ 'mime_type' => 'text/plain', |
|
71 |
+ }, |
|
72 |
+ { |
|
73 |
+ 'folder' => 'INBOX', |
|
74 |
+ 'from' => 'john.doe@example.com', |
|
75 |
+ 'to' => ['jane.doe@example.com', 'nanashi.gombeh@example.jp'], |
|
76 |
+ 'cc' => [], |
|
77 |
+ 'subject' => 'Re: some subject', |
|
78 |
+ 'body' => "Some reply\n", |
|
79 |
+ 'date' => '2014-05-09T17:00:00+09:00', |
|
80 |
+ 'has_attachment' => true, |
|
81 |
+ 'matches' => {}, |
|
82 |
+ 'mime_type' => 'text/plain', |
|
83 |
+ } |
|
84 |
+ ] |
|
85 |
+ end |
|
86 |
+ |
|
87 |
+ describe 'validations' do |
|
88 |
+ before do |
|
89 |
+ @checker.should be_valid |
|
90 |
+ end |
|
91 |
+ |
|
92 |
+ it 'should validate the integer fields' do |
|
93 |
+ @checker.options['expected_update_period_in_days'] = 'nonsense' |
|
94 |
+ @checker.should_not be_valid |
|
95 |
+ |
|
96 |
+ @checker.options['expected_update_period_in_days'] = '2' |
|
97 |
+ @checker.should be_valid |
|
98 |
+ |
|
99 |
+ @checker.options['port'] = -1 |
|
100 |
+ @checker.should_not be_valid |
|
101 |
+ |
|
102 |
+ @checker.options['port'] = 'imap' |
|
103 |
+ @checker.should_not be_valid |
|
104 |
+ |
|
105 |
+ @checker.options['port'] = '143' |
|
106 |
+ @checker.should be_valid |
|
107 |
+ |
|
108 |
+ @checker.options['port'] = 993 |
|
109 |
+ @checker.should be_valid |
|
110 |
+ end |
|
111 |
+ |
|
112 |
+ it 'should validate the boolean fields' do |
|
113 |
+ @checker.options['ssl'] = false |
|
114 |
+ @checker.should be_valid |
|
115 |
+ |
|
116 |
+ @checker.options['ssl'] = 'true' |
|
117 |
+ @checker.should_not be_valid |
|
118 |
+ end |
|
119 |
+ |
|
120 |
+ it 'should validate regexp conditions' do |
|
121 |
+ @checker.options['conditions'] = { |
|
122 |
+ 'subject' => '(foo' |
|
123 |
+ } |
|
124 |
+ @checker.should_not be_valid |
|
125 |
+ |
|
126 |
+ @checker.options['conditions'] = { |
|
127 |
+ 'body' => '***' |
|
128 |
+ } |
|
129 |
+ @checker.should_not be_valid |
|
130 |
+ |
|
131 |
+ @checker.options['conditions'] = { |
|
132 |
+ 'subject' => '\ARe:', |
|
133 |
+ 'body' => '(?<foo>http://\S+)' |
|
134 |
+ } |
|
135 |
+ @checker.should be_valid |
|
136 |
+ end |
|
137 |
+ end |
|
138 |
+ |
|
139 |
+ describe '#check' do |
|
140 |
+ it 'should check for mails and save memory' do |
|
141 |
+ lambda { @checker.check }.should change { Event.count }.by(2) |
|
142 |
+ @checker.memory['notified'].sort.should == @mails.map(&:message_id).sort |
|
143 |
+ @checker.memory['seen'].should == @mails.each_with_object({}) { |mail, seen| |
|
144 |
+ (seen[mail.uidvalidity] ||= []) << mail.uid |
|
145 |
+ } |
|
146 |
+ |
|
147 |
+ Event.last(2).map(&:payload) == @payloads |
|
148 |
+ |
|
149 |
+ lambda { @checker.check }.should_not change { Event.count } |
|
150 |
+ end |
|
151 |
+ |
|
152 |
+ it 'should narrow mails by To' do |
|
153 |
+ @checker.options['conditions']['to'] = 'John.Doe@*' |
|
154 |
+ |
|
155 |
+ lambda { @checker.check }.should change { Event.count }.by(1) |
|
156 |
+ @checker.memory['notified'].sort.should == [@mails.first.message_id] |
|
157 |
+ @checker.memory['seen'].should == @mails.each_with_object({}) { |mail, seen| |
|
158 |
+ (seen[mail.uidvalidity] ||= []) << mail.uid |
|
159 |
+ } |
|
160 |
+ |
|
161 |
+ Event.last.payload.should == @payloads.first |
|
162 |
+ |
|
163 |
+ lambda { @checker.check }.should_not change { Event.count } |
|
164 |
+ end |
|
165 |
+ |
|
166 |
+ it 'should perform regexp matching and save named captures' do |
|
167 |
+ @checker.options['conditions'].update( |
|
168 |
+ 'subject' => '\ARe: (?<a>.+)', |
|
169 |
+ 'body' => 'Some (?<b>.+) reply', |
|
170 |
+ ) |
|
171 |
+ |
|
172 |
+ lambda { @checker.check }.should change { Event.count }.by(1) |
|
173 |
+ @checker.memory['notified'].sort.should == [@mails.last.message_id] |
|
174 |
+ @checker.memory['seen'].should == @mails.each_with_object({}) { |mail, seen| |
|
175 |
+ (seen[mail.uidvalidity] ||= []) << mail.uid |
|
176 |
+ } |
|
177 |
+ |
|
178 |
+ Event.last.payload.should == @payloads.last.update( |
|
179 |
+ 'body' => "<div dir=\"ltr\">Some HTML reply<br></div>\n", |
|
180 |
+ 'matches' => { 'a' => 'some subject', 'b' => 'HTML' }, |
|
181 |
+ 'mime_type' => 'text/html', |
|
182 |
+ ) |
|
183 |
+ |
|
184 |
+ lambda { @checker.check }.should_not change { Event.count } |
|
185 |
+ end |
|
186 |
+ |
|
187 |
+ it 'should narrow mails by has_attachment (true)' do |
|
188 |
+ @checker.options['conditions']['has_attachment'] = true |
|
189 |
+ |
|
190 |
+ lambda { @checker.check }.should change { Event.count }.by(1) |
|
191 |
+ |
|
192 |
+ Event.last.payload['subject'].should == 'Re: some subject' |
|
193 |
+ end |
|
194 |
+ |
|
195 |
+ it 'should narrow mails by has_attachment (false)' do |
|
196 |
+ @checker.options['conditions']['has_attachment'] = false |
|
197 |
+ |
|
198 |
+ lambda { @checker.check }.should change { Event.count }.by(1) |
|
199 |
+ |
|
200 |
+ Event.last.payload['subject'].should == 'some subject' |
|
201 |
+ end |
|
202 |
+ |
|
203 |
+ it 'should narrow mail parts by MIME types' do |
|
204 |
+ @checker.options['mime_types'] = %w[text/plain] |
|
205 |
+ @checker.options['conditions'].update( |
|
206 |
+ 'subject' => '\ARe: (?<a>.+)', |
|
207 |
+ 'body' => 'Some (?<b>.+) reply', |
|
208 |
+ ) |
|
209 |
+ |
|
210 |
+ lambda { @checker.check }.should_not change { Event.count } |
|
211 |
+ @checker.memory['notified'].sort.should == [] |
|
212 |
+ @checker.memory['seen'].should == @mails.each_with_object({}) { |mail, seen| |
|
213 |
+ (seen[mail.uidvalidity] ||= []) << mail.uid |
|
214 |
+ } |
|
215 |
+ end |
|
216 |
+ |
|
217 |
+ it 'should never mark mails as read unless mark_as_read is true' do |
|
218 |
+ @mails.each { |mail| |
|
219 |
+ stub(mail).mark_as_read.never |
|
220 |
+ } |
|
221 |
+ lambda { @checker.check }.should change { Event.count }.by(2) |
|
222 |
+ end |
|
223 |
+ |
|
224 |
+ it 'should mark mails as read if mark_as_read is true' do |
|
225 |
+ @checker.options['mark_as_read'] = true |
|
226 |
+ @mails.each { |mail| |
|
227 |
+ stub(mail).mark_as_read.once |
|
228 |
+ } |
|
229 |
+ lambda { @checker.check }.should change { Event.count }.by(2) |
|
230 |
+ end |
|
231 |
+ |
|
232 |
+ it 'should create just one event for multiple mails with the same Message-Id' do |
|
233 |
+ @mails.first.message_id = @mails.last.message_id |
|
234 |
+ @checker.options['mark_as_read'] = true |
|
235 |
+ @mails.each { |mail| |
|
236 |
+ stub(mail).mark_as_read.once |
|
237 |
+ } |
|
238 |
+ lambda { @checker.check }.should change { Event.count }.by(1) |
|
239 |
+ end |
|
240 |
+ end |
|
241 |
+ end |
|
242 |
+end |